# 拓扑图实现导出大数据量时的图片

# 背景

由于浏览器对canvas有最大尺寸限制,导致在大数据量时(canvas很宽很宽)会导出图片失败,需要想别的办法去解决。

# 方案一:限制最大尺寸

最开始了解到canvas有最大尺寸后,想到的方案是给一个最大尺寸,将canvas的包围盒尺寸按比例缩小。

# 等比缩小

这里给个经验值20000。

// 做个最大尺寸的限制,避免超出后截不全
const oWidth = vWidth;
// 最大高度
const maxHeight = 20000;
// 最大宽度
const maxWidth = 20000;
if (vWidth > maxWidth && vHeight > maxHeight) {
  // 更宽更高,
  if (vHeight / vWidth > maxHeight / maxWidth) {
    // 更加严重的高窄型,确定最大高,压缩宽度
    vHeight = maxHeight;
    vWidth = maxHeight * (vWidth / vHeight);
  } else {
    // 更加严重的矮宽型, 确定最大宽,压缩高度
    vWidth = maxWidth;
    vHeight = maxWidth * (vHeight / vWidth);
  }
} else if (vWidth > maxWidth && vHeight <= maxHeight) {
  // 更宽,但比较矮,以maxWidth作为基准
  vWidth = maxWidth;
  vHeight = maxWidth * (vHeight / vWidth);
} else if (vWidth <= maxWidth && vHeight > maxHeight) {
  // 比较窄,但很高,取maxHight为基准
  vHeight = maxHeight;
  vWidth = maxHeight * (vWidth / vHeight);
} else {
  // 符合宽高限制,不做压缩
}

# 改变矩阵运算的缩放值

matrix的0和4位是缩放比例,这里除以之前缩小的比例得到正确的值。

const rate = oWidth / vWidth;

matrix[0] /= rate;
matrix[4] /= rate;

# 导出

之后就是按照g6.js的api去导出图片即可。

# 解决图片模糊问题

这里通过改变设备的像素比来解决。

导出前:

window.devicePixelRatio = 3;

导出后恢复原像素比。

# 缺点

这种方案在设备数量少的时候效果还是可以的,当数量大了后canvas实在是太大了,3000台设备能达到50000+px的尺寸,导致被过量压缩,清晰度下降甚至会看不清。

# 方案二:分批绘制后导出

既然是超出canvas限制尺寸了,那就缩小尺寸吧。

# 判断尺寸

const { width, height } = this.get('group').getCanvasBBox();

通过获取包围盒的尺寸,与设置的最大尺寸(20000,经验值)比较,大于则分批绘制数据然后导出图片,否则采用方案一直接导出。

# 切割数据

通过以下方法将画布切割成一个二维数组

const splitArr = Array.from(
  new Array(Math.ceil(height / this.MAX_CANVAS_SIZE)),
  () => Array.from(
    new Array(Math.ceil(width / this.MAX_CANVAS_SIZE)),
    () => ({ nodes: [], edges: [], display_options: this.parent._options.data.display_options })
  )
);

然后找出在该区域的节点

const { nodes, edges } = this.save();
for (let rowIndex = 0; rowIndex < splitArr.length; rowIndex++) {
  for (let columnIndex = 0; columnIndex < splitArr[0].length; columnIndex++) {
    const areaMinX = minX + columnIndex * this.MAX_CANVAS_SIZE;
    const areaMinY = minY + rowIndex * this.MAX_CANVAS_SIZE;
    const areaMaxX = areaMinX + this.MAX_CANVAS_SIZE;
    const areaMaxY = areaMinY + this.MAX_CANVAS_SIZE;
    const nodesMap = new Map();
    nodes.forEach(node => {
      const { x, y, id } = node;
      // 在该区域内
      if (x >= areaMinX && x < areaMaxX &&
        y >= areaMinY && y < areaMaxY &&
        this.findById(id).isVisible()
      ) {
        splitArr[rowIndex][columnIndex].nodes.push(node);
        nodesMap.set(id, 1);
      }
    });
    edges.forEach(edge => {
      const { source, target } = edge;
      if (nodesMap.has(source) || nodesMap.has(target)) {
        splitArr[rowIndex][columnIndex].edges.push(edge);
      }
    });
  }
}

# js队列

下面的方法需要用到队列,这里手动实现一个。

原理就是用数组记录一个promise队列,使用时等前一个结束后再执行下一个。

/**
 * promise队列
 */
export default class Queue {
  constructor() {
    this.queue = [];
  }

  push(task) {
    this.queue.push(task);
  }

  execute() {
    const res = [];
    return new Promise(resolve => {
      this.loop(res, resolve);
    });
  }

  loop(res, resolve) {
    const nextTask = this.queue.shift();
    if (nextTask) {
      nextTask().then((result) => {
        res.push(result);
        this.loop(res, resolve);
      });
    } else {
      resolve(res);
    }
  }
}

# 分批渲染

# 创建个新拓扑实例

const graphOptions = cloneDeep(this.parent._options);
const div = document.createElement('div');
div.style.width = `${graphOptions.width}px`;
div.style.height = `${graphOptions.height}px`;
div.style.position = 'absolute';
div.style.left = '-999999999px';
document.body.appendChild(div);
graphOptions.container = div;
graphOptions.data = null;
graphOptions.devMode.mock.enable = false;
graphOptions.plugins = graphOptions.pluginsOption;

const newGraph = new UEDGraph(graphOptions);

# 创建队列

const queue = new Queue();

// 将之前的二维数组循环推入
queue.push(async (resolve) => {
  newGraph.graph.clear();
  newGraph.graph.on(CUSTOM_EVENT.updateEnd, async () => {
    await this.sleep(2000);
    await newGraph.graph.downloadFullImage(options);
    resolve();
  }, true);
  await newGraph.updateGraph(
    splitArr[rowIndex][columnIndex],
    {
      needCalculatePosition: false,
      showMaxNodeBranch: false,
      autoFit: false,
      showLoading: false,
      needTransform: false
    }
  );
});

在渲染完数据后执行图片的导出。

# 执行

await queue.execute();
document.body.removeChild(div);
newGraph.destroy();

# 缺点

这种方案的问题还是比较多的。

  1. 当连线设备在范围外时,会将这部分连线丢失。
  2. 截出来的图片尺寸不一致

由于问题1无法解决,所以放弃了这种方案。

# 方案三:使用puppeteer截图

# 安装

npm i puppeteer-core

由于开发环境在内网,还需要手动下载chrome包

下载教程可参考http://www.qb5200.com/article/346953.html

# 使用

const puppeteer = require('puppeteer-core');
const path = require('path');
 
(async () => {
 const browser = await puppeteer.launch({
  // 这里注意路径指向可执行的浏览器。
  // 各平台路径可以在 node_modules/puppeteer-core/lib/BrowserFetcher.js 中找到
  // Mac 为 '下载文件解压路径/Chromium.app/Contents/MacOS/Chromium'
  // Linux 为 '下载文件解压路径/chrome'
  // Windows 为 '下载文件解压路径/chrome.exe'
  executablePath: path.resolve('./chrome-win/chrome.exe')
 });
 const page = await browser.newPage();
 await page.setViewport({
  width: 1920,
  height: 1080
});
 await page.goto('http://127.0.0.1:8080/example/tree/', {
  waitUntil: 'networkidle0',
  timeout: 0
 });
//  await page.screenshot({
//   path: 'marx-blog.png',
//   fullPage: true,
// });

let canvas = await page.$('#ued-graph canvas');
//调用页面内Dom对象的screenshot 方法进行截图
canvas.screenshot({
    path:'form.png'
});
 await browser.close();
})();

可以直接对页面截图,或者指定dom。

尝试了都只能对当前显示内容进行截图,无法去滚动canvas中的内容。失败告终

# 方案四:做矩阵运算

G6.js可以导出可视区域和整个图的图片,于是通过查看源码,发现导出可视区域的原理就是canvas只支持将显示了的元素导出。整个图是通过重新渲染一个canvas,然后将所有元素的包围盒的尺寸赋给它,即让这个虚拟的canvas直接显示所有的元素,然后再通过toDataURL导出。

# 原理

知道原理后,就想到了下面这招:

给定最大尺寸,超出尺寸的不像方案一一样去等比缩小,而是对拓扑做偏移。

相当于在有限的尺寸上显示部分元素,导出后再做下一个区域的偏移,直到将所有区域完成导出。

# 实现

# 判断尺寸

同样,判断尺寸,超出做分批导出的操作

# 分批

const { width } = this.get('group').getCanvasBBox();
const length = Math.ceil(width / this.MAX_CANVAS_SIZE);
const maxWidth = width / length;
const queue = new Queue();
for (let i = 0; i < length; i++) {
  queue.push(async () => {
    return await this.downloadFullImage({
      ...options,
      maxWidth,
      splitIndex: i,
      download: true
    });
  });
}
return await queue.execute();

循环需要分批的次数去截图。

这里对G6downloadFullImage做了重写,使用promise包裹。

调用时根据maxWidth字段创建的虚拟canvas的宽度。

const realWidth = maxWidth || vWidth;
const canvasOptions = {
  container: vContainerDOM,
  height: vHeight,
  // 有给最大宽度就用最大的
  width: realWidth
};

通过splitIndex做矩阵运算的偏移

// 根据切割的索引往左做偏移
if (splitIndex !== undefined) {
  matrix[6] = maxWidth * splitIndex * -1;
}

返回的格式改为状态和base64格式。

[status, dataURL]

# 服务端实现图片拼接

成功导出图片后,就需要服务端去做图片的拼接。

因为前端拼接图片也是通过canvas去实现,这里导出的图片本来就超过canvas的尺寸限制了,所以得借助服务端的能力去实现。

至此,终于实现了大数据量下的canvas图片导出功能。